/* $Id: ClientFramework.java,v 1.68 2010/11/26 20:07:27 martinfuchs Exp $ */
/***************************************************************************
 *                   (C) Copyright 2003-2010 - Marauroa                    *
 ***************************************************************************
 ***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
package marauroa.client;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import marauroa.client.net.INetworkClientManagerInterface;
import marauroa.client.net.TCPNetworkClientManager;
import marauroa.common.Log4J;
import marauroa.common.crypto.Hash;
import marauroa.common.crypto.RSAPublicKey;
import marauroa.common.game.AccountResult;
import marauroa.common.game.CharacterResult;
import marauroa.common.game.RPAction;
import marauroa.common.game.RPObject;
import marauroa.common.game.Result;
import marauroa.common.net.InvalidVersionException;
import marauroa.common.net.message.Message;
import marauroa.common.net.message.MessageC2SAction;
import marauroa.common.net.message.MessageC2SChooseCharacter;
import marauroa.common.net.message.MessageC2SCreateAccount;
import marauroa.common.net.message.MessageC2SCreateCharacter;
import marauroa.common.net.message.MessageC2SKeepAlive;
import marauroa.common.net.message.MessageC2SLoginRequestKey;
import marauroa.common.net.message.MessageC2SLoginSendNonceNameAndPassword;
import marauroa.common.net.message.MessageC2SLoginSendNonceNamePasswordAndSeed;
import marauroa.common.net.message.MessageC2SLoginSendPromise;
import marauroa.common.net.message.MessageC2SLogout;
import marauroa.common.net.message.MessageC2SOutOfSync;
import marauroa.common.net.message.MessageC2STransferACK;
import marauroa.common.net.message.MessageS2CCharacterList;
import marauroa.common.net.message.MessageS2CConnectNACK;
import marauroa.common.net.message.MessageS2CCreateAccountACK;
import marauroa.common.net.message.MessageS2CCreateAccountNACK;
import marauroa.common.net.message.MessageS2CCreateCharacterACK;
import marauroa.common.net.message.MessageS2CCreateCharacterNACK;
import marauroa.common.net.message.MessageS2CInvalidMessage;
import marauroa.common.net.message.MessageS2CLoginACK;
import marauroa.common.net.message.MessageS2CLoginMessageNACK;
import marauroa.common.net.message.MessageS2CLoginNACK;
import marauroa.common.net.message.MessageS2CLoginSendKey;
import marauroa.common.net.message.MessageS2CLoginSendNonce;
import marauroa.common.net.message.MessageS2CMigrate;
import marauroa.common.net.message.MessageS2CPerception;
import marauroa.common.net.message.MessageS2CServerInfo;
import marauroa.common.net.message.MessageS2CTransfer;
import marauroa.common.net.message.MessageS2CTransferREQ;
import marauroa.common.net.message.TransferContent;

/**
 * It is a wrapper over all the things that the client should do. You should
 * extend this class at your game.
 *
 * @author miguel
 *
 */
public abstract class ClientFramework {

    /** the logger instance. */
    private static final marauroa.common.Logger logger = Log4J.getLogger(ClientFramework.class);
    /** How long we should wait for connect. */
    public final static int TIMEOUT = 100000;
    /** wait longer for an login to compensate for slow database operation */
    private final static int TIMEOUT_EXTENDED = 300000;
    private int perceptionsCount;
    /**
     * We keep an instance of network manager to be able to communicate with
     * server.
     */
    protected INetworkClientManagerInterface netMan;
    /** We keep a list of all messages waiting for being processed. */
    private final List<Message> messages;

    /**
     * Constructor.
     *
     * @param loggingProperties
     *            contains the name of the file that configure the logging
     *            system.
     */
    public ClientFramework(String loggingProperties) {
        Log4J.init(loggingProperties);

        messages = new LinkedList<Message>();
        perceptionsCount = 0;
    }

    /**
     * Constructor.
     *
     */
    public ClientFramework() {
        Log4J.init();

        messages = new LinkedList<Message>();
        perceptionsCount = 0;
    }

    public INetworkClientManagerInterface getNetMan() {
        return netMan;
    }

    public void setNetMan(INetworkClientManagerInterface netMan) {
        this.netMan = netMan;
    }

    /**
     * Call this method to connect to server. This method just configure the
     * connection, it doesn't send anything
     *
     * @param host
     *            server host name
     * @param port
     *            server port number
     * @throws IOException
     *             if connection is not possible
     */
    public void connect(String host, int port) throws IOException {
        netMan = new TCPNetworkClientManager(host, port);
    }

    /**
     * Call this method to connect to server using a proxy-server inbetween.
     * This method just configure the connection, it doesn't send anything.
     *
     * @param proxy proxy server to use for the connection
     * @param serverAddress marauroa server (final destination)
     * @throws IOException if connection is not possible
     */
    public void connect(Proxy proxy, InetSocketAddress serverAddress) throws IOException {
        netMan = new TCPNetworkClientManager(proxy, serverAddress);
    }

    /**
     * Retrieves a message from network manager.
     *
     * @return a message
     * @throws InvalidVersionException
     * @throws TimeoutException
     *             if there is no message available in TIMEOUT milliseconds.
     * @throws BannedAddressException
     */
    private synchronized Message getMessage(int timeout) throws InvalidVersionException, TimeoutException,
            BannedAddressException {
        Message msg = null;

        if (messages.isEmpty()) {
            msg = netMan.getMessage(timeout);

            if (msg instanceof MessageS2CConnectNACK) {
                throw new BannedAddressException();
            }

            if (msg == null) {
                throw new TimeoutException();
            }
        } else {
            msg = messages.remove(0);
        }

        logger.debug("CF getMessage: " + msg);
        return msg;
    }

    /**
     * Request a synchronization with server. It shouldn't be needed now that we
     * are using TCP.
     */
    @Deprecated
    public synchronized void resync() {
        MessageC2SOutOfSync msg = new MessageC2SOutOfSync();
        netMan.addMessage(msg);
    }

    /**
     * Login to server using the given username and password.
     *
     * @param username
     *            Player username
     * @param password
     *            Player password
     * @throws InvalidVersionException
     *             if we are not using a compatible version
     * @throws TimeoutException
     *             if timeout happens while waiting for the message.
     * @throws LoginFailedException
     *             if login is rejected
     * @throws BannedAddressException
     */
    public synchronized void login(String username, String password)
            throws InvalidVersionException, TimeoutException, LoginFailedException,
            BannedAddressException {
        login(username, password, null);
    }

    /**
     * Login to server using the given username and password.
     *
     * @param username
     *            Player username
     * @param password
     *            Player password
     * @param seed
     *            preauthentication seed
     * @throws InvalidVersionException
     *             if we are not using a compatible version
     * @throws TimeoutException
     *             if timeout happens while waiting for the message.
     * @throws LoginFailedException
     *             if login is rejected
     * @throws BannedAddressException
     */
    @SuppressWarnings("null")
    public synchronized void login(String username, String password, String seed)
            throws InvalidVersionException, TimeoutException, LoginFailedException,
            BannedAddressException {
        int received = 0;
        RSAPublicKey key = null;
        byte[] clientNonce = null;
        byte[] serverNonce = null;

        /* Send to server a login request and indicate the game name and version */
        netMan.addMessage(new MessageC2SLoginRequestKey(null, getGameName(), getVersionNumber()));

        int timeout = TIMEOUT;
        while (received < 3) {
            Message msg = getMessage(timeout);
            // Okay, now  we know that there is a marauroa server responding to the handshake.
            // We can give it more time for the next steps in case the database is slow.
            // Loging heavily depends on the database because number of failed logins for both
            // ip-address and username, banstatus, username&password have to be checked. And
            // the list of characters needs to be loaded from the database.
            timeout = TIMEOUT_EXTENDED;

            switch (msg.getType()) {
                case S2C_INVALIDMESSAGE: {
                    throw new LoginFailedException(((MessageS2CInvalidMessage) msg).getReason());
                }
                /* Server sends its public RSA key */
                case S2C_LOGIN_SENDKEY: {
                    logger.debug("Received Key");
                    key = ((MessageS2CLoginSendKey) msg).getKey();

                    clientNonce = Hash.random(Hash.hashLength());
                    netMan.addMessage(new MessageC2SLoginSendPromise(null, Hash.hash(clientNonce)));
                    break;
                }
                /* Server sends a random big integer */
                case S2C_LOGIN_SENDNONCE: {
                    logger.debug("Received Server Nonce");
                    if (serverNonce != null) {
                        throw new LoginFailedException("Already received a serverNonce");
                    }

                    serverNonce = ((MessageS2CLoginSendNonce) msg).getHash();
                    byte[] b1 = Hash.xor(clientNonce, serverNonce);
                    if (b1 == null) {
                        throw new LoginFailedException("Incorrect hash b1");
                    }

                    byte[] b2 = Hash.xor(b1, Hash.hash(password));
                    if (b2 == null) {
                        throw new LoginFailedException("Incorrect hash b2");
                    }

                    byte[] cryptedPassword = key.encodeByteArray(b2);
                    if (seed != null) {
                        byte[] bs = null;
                        try {
                            bs = seed.getBytes("UTF-8");
                        } catch (UnsupportedEncodingException e) {
                            logger.error(e, e);
                        }
                        if (bs.length != 16) {
                            throw new LoginFailedException("Seed has not the correct length.");
                        }
                        byte[] b3 = null;
                        b3 = Hash.xor(b1, bs);
                        if (b3 == null) {
                            throw new LoginFailedException("Incorrect hash seed");
                        }
                        byte[] cryptedSeed = key.encodeByteArray(b3);
                        netMan.addMessage(new MessageC2SLoginSendNonceNamePasswordAndSeed(null,
                                clientNonce, username, cryptedPassword, cryptedSeed));
                    } else {
                        netMan.addMessage(new MessageC2SLoginSendNonceNameAndPassword(null,
                                clientNonce, username, cryptedPassword));
                    }
                    break;
                }
                /* Server replied with ACK to login operation */
                case S2C_LOGIN_ACK:
                    logger.debug("Login correct");

                    onPreviousLogins(((MessageS2CLoginACK) msg).getPreviousLogins());

                    received++;
                    break;
                /* Server send the character list */
                case S2C_CHARACTERLIST:
                    logger.debug("Received Character list");

                    /*
                     * We notify client of characters by calling the callback
                     * method.
                     */
                    String[] characters = ((MessageS2CCharacterList) msg).getCharacters();
                    onAvailableCharacters(characters);
                    Map<String, RPObject> characterDetails = ((MessageS2CCharacterList) msg).getCharacterDetails();
                    onAvailableCharacterDetails(characterDetails);
                    received++;
                    break;
                /*
                 * Server sends the server info message with information about
                 * versions, homepage, etc...
                 */
                case S2C_SERVERINFO:
                    logger.debug("Received Server info");
                    String[] info = ((MessageS2CServerInfo) msg).getContents();

                    /* We notify client of this info by calling the callback method. */
                    onServerInfo(info);
                    received++;
                    break;
                /* Login failed, explain reason on event */
                case S2C_LOGIN_NACK:
                    MessageS2CLoginNACK msgNACK = (MessageS2CLoginNACK) msg;
                    logger.debug("Login failed. Reason: " + msgNACK.getResolution());
                    throw new LoginFailedException(msgNACK.getResolution(), msgNACK.getResolutionCode());

                /* Login failed, explain reason on event */
                case S2C_LOGIN_MESSAGE_NACK:
                    MessageS2CLoginMessageNACK msgMessageNACK = (MessageS2CLoginMessageNACK) msg;
                    logger.debug("Login failed. Reason: " + msgMessageNACK.getReason());
                    throw new LoginFailedException(msgMessageNACK.getReason());

                /* If message doesn't match, store it, someone will need it. */
                default:
                    messages.add(msg);
            }
        }
    }

    /**
     * After login allows you to choose a character to play
     *
     * @param character
     *            name of the character we want to play with.
     * @return true if choosing character is successful.
     * @throws InvalidVersionException
     *             if we are not using a compatible version
     * @throws TimeoutException
     *             if timeout happens while waiting for the message.
     * @throws BannedAddressException
     */
    public synchronized boolean chooseCharacter(String character) throws TimeoutException,
            InvalidVersionException, BannedAddressException {
        Message msgCC = new MessageC2SChooseCharacter(null, character);
        netMan.addMessage(msgCC);

        int received = 0;

        while (received != 1) {
            Message msg = getMessage(TIMEOUT_EXTENDED);

            switch (msg.getType()) {
                /* Server accepted the character we chose */
                case S2C_CHOOSECHARACTER_ACK:
                    logger.debug("Choose Character ACK");
                    return true;
                /* Server rejected the character we chose. No reason */
                case S2C_CHOOSECHARACTER_NACK:
                    logger.debug("Choose Character NACK");
                    return false;
                default:
                    messages.add(msg);
            }
        }

        // Unreachable, but makes javac happy
        return false;
    }

    /**
     * Request server to create an account on server.
     *
     * @param username
     *            the player desired username
     * @param password
     *            the player password
     * @param email
     *            player's email for notifications and/or password reset.
     * @return AccountResult
     * @throws InvalidVersionException
     *             if we are not using a compatible version
     * @throws TimeoutException
     *             if timeout happens while waiting for the message.
     * @throws BannedAddressException
     */
    public synchronized AccountResult createAccount(String username, String password, String email)
            throws TimeoutException, InvalidVersionException, BannedAddressException {
        Message msgCA = new MessageC2SCreateAccount(null, username, password, email);

        netMan.addMessage(msgCA);

        int received = 0;

        AccountResult result = null;

        while (received != 1) {
            Message msg = getMessage(TIMEOUT_EXTENDED);

            switch (msg.getType()) {
                case S2C_INVALIDMESSAGE: {
                    result = new AccountResult(Result.FAILED_EXCEPTION, username);
                    break;
                }
                /* Account was created */
                case S2C_CREATEACCOUNT_ACK:
                    logger.debug("Create account ACK");

                    MessageS2CCreateAccountACK msgack = (MessageS2CCreateAccountACK) msg;
                    result = new AccountResult(Result.OK_CREATED, msgack.getUsername());

                    received++;
                    break;

                /* Account was not created. Reason explained on event. */
                case S2C_CREATEACCOUNT_NACK:
                    logger.debug("Create account NACK");
                    MessageS2CCreateAccountNACK nack = (MessageS2CCreateAccountNACK) msg;
                    result = new AccountResult(nack.getResolutionCode(), username);

                    received++;
                    break;
                default:
                    logger.debug("Unexpected method while waiting for confirmation of account creation: " + msg);
            }
        }

        return result;
    }

    /**
     * Request server to create a character on server. You must have
     * successfully logged into server before invoking this method.
     *
     * @param character
     *            the character to create
     * @param template
     *            an object template to create the player avatar.
     * @return CharacterResult
     * @throws InvalidVersionException
     *             if we are not using a compatible version
     * @throws TimeoutException
     *             if timeout happens while waiting for the message.
     * @throws BannedAddressException
     */
    public synchronized CharacterResult createCharacter(String character, RPObject template)
            throws TimeoutException, InvalidVersionException, BannedAddressException {
        Message msgCA = new MessageC2SCreateCharacter(null, character, template);

        netMan.addMessage(msgCA);

        int received = 0;

        CharacterResult result = null;

        while (received != 2) {
            Message msg = getMessage(TIMEOUT_EXTENDED);

            switch (msg.getType()) {
                /* Account was created */
                case S2C_CREATECHARACTER_ACK:
                    logger.debug("Create character ACK");

                    MessageS2CCreateCharacterACK msgack = (MessageS2CCreateCharacterACK) msg;

                    result = new CharacterResult(Result.OK_CREATED, msgack.getCharacter(), msgack.getTemplate());
                    received++;
                    break;

                /* Server send the character list */
                case S2C_CHARACTERLIST:
                    logger.debug("Received Character list");
                    /*
                     * We notify client of characters by calling the callback
                     * method.
                     */
                    String[] characters = ((MessageS2CCharacterList) msg).getCharacters();
                    onAvailableCharacters(characters);
                    Map<String, RPObject> characterDetails = ((MessageS2CCharacterList) msg).getCharacterDetails();
                    onAvailableCharacterDetails(characterDetails);

                    received++;
                    break;

                /* Account was not created. Reason explained on event and return. */
                case S2C_CREATECHARACTER_NACK:
                    logger.debug("Create character NACK");
                    MessageS2CCreateCharacterNACK reply = (MessageS2CCreateCharacterNACK) msg;

                    result = new CharacterResult(reply.getResolutionCode(), character, template);
                    return result;
            }
        }

        return result;
    }

    /**
     * Sends a RPAction to server
     *
     * @param action
     *            the action to send to server.
     */
    public synchronized void send(RPAction action) {
        /*
         * Each time we send an action we are confirming server our presence, so we
         * reset the counter to avoid sending keep alive messages.
         */
        perceptionsCount = 0;

        MessageC2SAction msgAction = new MessageC2SAction(null, action);
        netMan.addMessage(msgAction);
    }

    /**
     * Request logout of server
     *
     * @return true if we have successfully logout or false if server rejects to
     *         logout our player and maintain it on game world.
     * @throws InvalidVersionException
     *             if we are not using a compatible version
     * @throws TimeoutException
     *             if timeout happens while waiting for the message.
     * @throws BannedAddressException
     */
    public synchronized boolean logout() throws InvalidVersionException, TimeoutException,
            BannedAddressException {
        Message msgL = new MessageC2SLogout(null);

        netMan.addMessage(msgL);
        int received = 0;

        while (received != 1) {
            Message msg = getMessage(TIMEOUT);
            switch (msg.getType()) {
                case S2C_LOGOUT_ACK:
                    logger.debug("Logout ACK");
                    return true;
                case S2C_LOGOUT_NACK:
                    logger.debug("Logout NACK");
                    return false;
            }
        }

        return false;
    }

    /**
     * Disconnect the socket and finish the network communications.
     *
     */
    public void close() {
        if (netMan != null) {
            /*
             * Netman is null while we don't call connect method.
             */
            netMan.finish();
        }
    }

    /**
     * Call this method to get and apply messages
     *
     * @param delta
     *            unused
     * @return true if new messages were received.
     */
    public synchronized boolean loop(@SuppressWarnings("unused") int delta) {
        boolean receivedMessages = false;
        boolean restartLoop = false;
        /* Check network for new messages. */
        messages.addAll(((TCPNetworkClientManager) netMan).getMessages());
        /* For all the received messages do */
        for (Message msg : messages) {
            receivedMessages = true;

            switch (msg.getType()) {
                /* It can be a perception message */
                case S2C_PERCEPTION: {
                    perceptionsCount++;

                    /*
                     * Only once each 30 perceptions we tell that we are alive.
                     */
                    if (perceptionsCount % 30 + 1 == 30) {
                        MessageC2SKeepAlive msgAlive = new MessageC2SKeepAlive();
                        netMan.addMessage(msgAlive);
                    }

                    logger.debug("Processing Message Perception");
                    MessageS2CPerception msgPer = (MessageS2CPerception) msg;
                    onPerception(msgPer);

                    break;
                }

                /* or it can be a transfer request message */
                case S2C_TRANSFER_REQ: {
                    logger.debug("Processing Content Transfer Request");
                    List<TransferContent> items = ((MessageS2CTransferREQ) msg).getContents();

                    items = onTransferREQ(items);

                    MessageC2STransferACK reply = new MessageC2STransferACK(null, items);
                    netMan.addMessage(reply);

                    break;
                }

                /* or it can be the data transfer itself */
                case S2C_TRANSFER: {
                    logger.debug("Processing Content Transfer");
                    List<TransferContent> items = ((MessageS2CTransfer) msg).getContents();
                    onTransfer(items);

                    break;
                }

                /* or it can be a request to migrate*/
                case S2C_MIGRATE:{
                    onMigrate((MessageS2CMigrate) msg);
                    break;
                }
            }
            if(msg.getType()==Message.MessageType.S2C_MIGRATE){
                restartLoop = true;
                break;
            }
        }
        //if(!restartLoop)
            messages.clear();

        return receivedMessages;
    }

    /**
     * sends a KeepAliveMessage, this is automatically done in game, but you may
     * be required to call this method very five minutes in pre game.
     */
    public synchronized void sendKeepAlive() {
        MessageC2SKeepAlive msg = new MessageC2SKeepAlive();
        netMan.addMessage(msg);
    }

    /**
     * Are we connected to the server?
     *
     * @return true unless it is sure that we are disconnected
     */
    public boolean getConnectionState() {
        return netMan.getConnectionState();
    }

    /**
     * It is called when a perception arrives so you can choose how to apply the
     * perception.
     *
     * @param message
     *            the perception message itself.
     */
    abstract protected void onPerception(MessageS2CPerception message);

    /**
     * is called before a content transfer is started.
     *
     * <code> items </code> contains a list of names and timestamp.
     * That information can be used to decide if a transfer from server is needed.
     * By setting attribute ack to true in a TransferContent it will be acknowledged.
     * All acknowledges items in the returned List, will be transfered by server.
     *
     * @param items
     *            in this list by default all items.ack attributes are set to false;
     * @return the list of approved and rejected items.
     */
    abstract protected List<TransferContent> onTransferREQ(List<TransferContent> items);

    /**
     * It is called when we get a transfer of content
     *
     * @param items
     *            the transfered items.
     */
    abstract protected void onTransfer(List<TransferContent> items);

    /**
     * It is called when we get the list of characters
     *
     * @param characters
     *            the characters we have available at this account.
     */
    abstract protected void onAvailableCharacters(String[] characters);

    /**
     * It is called when we get the list of characters
     *
     * @param characters
     *            the characters we have available at this account.
     */
    protected void onAvailableCharacterDetails(@SuppressWarnings("unused") Map<String, RPObject> characters) {
        // stub
    }

    /**
     * It is called when we get the list of server information strings
     *
     * @param info
     *            the list of server strings with information.
     */
    abstract protected void onServerInfo(String[] info);

    /**
     * Returns the name of the game that this client implements
     *
     * @return the name of the game that this client implements
     */
    abstract protected String getGameName();

    /**
     * Returns the version number of the game
     *
     * @return the version number of the game
     */
    abstract protected String getVersionNumber();

    /**
     * Call the client with a list of previous logins.
     * @param previousLogins a list of strings with the previous logins
     */
    abstract protected void onPreviousLogins(List<String> previousLogins);

    /**
     * Only called from the server. To synchronize two or more servers
     * functionalities of client framework is heavily used. This is a hack.
     */
    abstract public void sendSyncConfirmS2S();

    abstract public void onMigrate(MessageS2CMigrate msg);


}
